iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Modern Web

Laravel 12 開發者幸福度升級指南系列 第 19

Day 19:laravel 12 的 N+1 問題以及解法

  • 分享至 

  • xImage
  •  

透過不同的發布策略,減少線上問題所帶來的影響之後。

我們就可以相對安心的處理專案了。

不過,有的問題是在人數少的時候還不會遇到狀況,但是在專案運行一段時間,資料量變多或者人數變多時,才會開始發生問題

今天我們來聊這一塊的狀況

N+1 問題

N+1 問題是在使用 ORM 的時候,由於關聯非常的直觀好用,有時候我們會忽略背後的實作,是有可能導致問題的。

我們來看看一個範例,我們有兩個 Model UserBook,之間是一對多關係

class User extends Model
{
    public function books(): HasMany
    {
        return $this->hasMany(Book::class);
    }
}

這時候,如果我們寫了一個迴圈,試著取得所有用戶,各自擁有的所有書本

Route::get('/books', function () {
    $users = User::all();
    $books = collect();
    foreach ($users as $user) {
        $userData = collect();
        foreach ($user->books as $book) {
            $userData->push($book->title);
        }
        $books->push($user);
    }
    return $books;
})->name('books');

就可以看到所有的書籍內容。

這段程式在剛開始運作,是不會有任何問題的,但是,隨著資料越來越多,我們就會發現這段程式明顯變慢了很多。

要理解這是怎麼回事,我們要使用一些工具,看看這段程式背後執行的 SQL Query

我們可以用 DB::enableQueryLog()DB::getQueryLog() 幫我們達成這件事情

Route::get('/books', function () {
    $users = User::all();
    $books = collect();
    foreach ($users as $user) {
        $userData = collect();
        foreach ($user->books as $book) {
            $userData->push($book->title);
        }
        $books->push($user);
    }
    return DB::getQueryLog();
})->name('books');

我們會看到

[
  {
    "query": "select * from \"users\"",
    "bindings": [],
    "time": 0.04
  },
  {
    "query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
    "bindings": [
      1
    ],
    "time": 0.2
  },
  {
    "query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
    "bindings": [
      2
    ],
    "time": 0.03
  },
  {
    "query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
    "bindings": [
      3
    ],
    "time": 0.02
  },
  {
    "query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
    "bindings": [
      4
    ],
    "time": 0.02
  }
]

這邊發生了什麼事情?

由於我們先透過了 User::all() 取出了所有的 user,然後在迴圈內才試著存取每個 user 裡面的 book。

所以在迴圈內,我們等於遇到一個 user,才去資料庫取出這個 user 對應的書籍。

這個作法有個術語,叫做 Lazy loading。

也就是說,要是我們有 N 個 user,那我們會在迴圈階段執行 N 次資料庫存取!

搭配上迴圈之前的 query,等於這段程式要跑 N+1 次 query。

在我們 user 越來越多的狀況下,這樣當然會對效能有不好的影響了。

避免 N+1 問題

要避免 N+1 問題,最簡單的方式,就是在前面取出資料時先將對應的資料取出來。

我們可以用 with() 來做到這件事情

Route::get('/books', function () {
    $users = User::with('books')->get();
    $books = collect();
    foreach ($users as $user) {
        $userData = collect();
        foreach ($user->books as $book) {
            $userData->push($book->title);
        }
        $books->push($user);
    }
    return DB::getQueryLog();
})->name('books');

這時我們看看 Query 的紀錄,可以看到

[
  {
    "query": "select * from \"users\"",
    "bindings": [],
    "time": 0.08
  },
  {
    "query": "select * from \"books\" where \"books\".\"user_id\" in (1, 2, 3, 4)",
    "bindings": [],
    "time": 0.17
  }
]

這邊由於 Laravel 知道我們後面將會需要使用 books 裡面的資料,所以直接用一個 Query 將所有用戶對應所有的 books 全部取出來。

這樣一來,就不會在迴圈裡面每次都存取一次資料庫了。

這個作法又稱為 eager loading。

Laravel 12 的 Model::preventLazyLoading()automaticallyEagerLoadRelationships()

話雖如此,當我們關聯變得複雜時,有時候總是會忘記或者漏掉對應的關聯要加入。

在 Laravel 12,特別加入了一個函式,可以一勞永逸的避免掉忘記加入關聯這個問題。

我們可以在 AppServierProvider.php 裡面加入

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading();
}

這時如果我們試著將前面的 User::with('books')->get() 改回 User::all()

我們就會看到錯誤訊息

# Illuminate\Database\LazyLoadingViolationException - Internal Server Error
Attempted to lazy load [books] on model [App\Models\User] but lazy loading is disabled.

PHP 8.4.8
Laravel 12.28.1
127.0.0.1:8000

## Stack Trace
...

如果我們不希望手寫所有的 with,我們可以使用 Model::automaticallyEagerLoadRelationships()

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::automaticallyEagerLoadRelationships();
}

這樣一來,程式在運作的時候會自動的幫我們抓出所有的關聯。

即使我們使用 User::all() 的方式,也不會出現 N+1 問題了

今天的部分就到這邊,我們明天見!


上一篇
Day 18:Feature Flags、Laravel Pennant 和不同發布模式觀念
下一篇
Day 20:在 Laravel 內加上快取,並在雲端上面部署
系列文
Laravel 12 開發者幸福度升級指南20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言